Skip to content

metal: DSpark drafter working end-to-end (3 ops + 4 continuous/drafter fixes)#2

Open
robotnursenyc wants to merge 2 commits into
Entrpi:batched-servingfrom
robotnursenyc:metal-dspark-kernels
Open

metal: DSpark drafter working end-to-end (3 ops + 4 continuous/drafter fixes)#2
robotnursenyc wants to merge 2 commits into
Entrpi:batched-servingfrom
robotnursenyc:metal-dspark-kernels

Conversation

@robotnursenyc

@robotnursenyc robotnursenyc commented Jul 19, 2026

Copy link
Copy Markdown

Makes the DSpark block drafter run end-to-end on Metal for DeepSeek-V4-Flash.

Two parts:

  1. The three previously-stubbed DSpark Metal ops: capture_mean, gather_concat, markov_step (original scope of this PR).
  2. Four correctness fixes on the continuous-batching + drafter path that were blocking the drafter (the first also blocked non-drafter continuous batching):
    • compressor_update_tensor — the banked emit wrote F32 pool/rms/rope output through a sizeof(float)-strided view of the F16 comp cache, corrupting one element per comp row → 100% NaN at the first compressed-KV attention layer once n_comp crosses the boundary. Fixed with a caller-scoped output_is_f16 flag (banked/F16 caller emits into an F32 scratch then copy_f32_to_f16; F32 callers unchanged).
    • ds4_gpu_matmul_f32_tensor was GEMV-only (n_tok!=1 → failure); the DSpark HC-mixer weight is F32 at n_tok=block size. Now runs per-row GEMVs; byte-identical for n_tok==1.
    • ds4_gpu_tensor_copy needs an open batch cb; the DSpark inject block ends it, so the rollback's restore copies failed. Open a cb around the rollback only when none is open (MTP path unchanged).
    • HC split/sinkhorn sigmoid 0.5*tanh(0.5*z)+0.5 overflows to NaN under Metal fast-math tanh for large-positive z. Clamp input to [-30,30] (saturated region → in-range values unchanged).

With all applied, the continuous path serves without serial fallback and the drafter produces real, accepted drafts. Acceptance is content-dependent (~68% / ~2.4× on repetitive text down to ~13% / ~1.2× on free prose, single-run, M3 Ultra, public 2-bit DSpark drafter) — see METAL_DSPARK.md for details, measurements, and credits.

Builds clean on Metal. Root-cause analysis and fixes produced with AI assistance (Claude) and reviewed before submission; measurements are single-machine and should be reproduced independently.

…oncat, markov_step)

Ports the CUDA kernels dspark_capture_mean_kernel, dspark_gather_concat_kernel,
and dspark_markov_step_kernel to Metal, replacing the CUDA-only stubs in
ds4_metal.m. Shapes, dtypes, and the markov argmax tie-break (strict-greater
scan, lowest index wins) match the CUDA originals; markov_step clamps its
threadgroup to the largest power of two <= maxTotalThreadsPerThreadgroup.

With these in place the drafter loads, arms, and validates (D1 gate) on a
Metal build; the spec loop itself is still gated by the separate cont-prefill
nonfinite-logits issue on Metal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robotnursenyc

Copy link
Copy Markdown
Author

Follow-up: we spent the evening root-causing bug 1 (cont prefill nonfinite seed logits at n >= 8 on Metal) and can narrow it considerably for you, though we have not fully killed it. Findings, all on M3 Ultra, q4 + q2 targets:

Where it lives. The cont serving prefill (pos0=0) takes the zero_prefix layer-major branch (ds4.c ~17052-17148 attn / ~17855-17933 indexer → ds4_gpu_compressor_prefill_tensor + metal_graph_refresh_ratio4_compressor_state). The per-seq multiseq emit sites (~16656/16880/17534/18113) never execute at prefill (batch_multiseq_emit is only set by the decode driver and the K-step selftest). First corruption is the first compressed-KV layer (layer 2); dense layers 0-1 stay clean; router expert ids stay sane.

What we ruled out empirically (each with the n=8/n=30 repro):

  • Stale Metal backend: transplanting a current-lineage Metal backend (June + Optimize Metal prefill and decode on all Apple Silicon chips  antirez/ds4#555-era, our production one) under the fork's ds4.c reproduces identically → the trigger is in the cont driver's interaction with the backend, not backend age.
  • DS4_METAL_MATH_SAFE=1, DS4_CUDA_FP8_KV=0, DS4_CUDA_FP4_INDEX=0: no change.
  • Compressor state-store semantics: restoring/verifying the full internal-store compressor_update path: no change (it is not on the zero_prefix path anyway).
  • Explicit memoryBarrierWithScope:MTLBarrierScopeBuffers between the score-scratch write (encode_compressor_score_with_ape) and its pool/pack readers inside compressor_prefill_tensor and ..._ratio4_replay_tensor: no change.

Two observations that should localize it for you:

  1. With the built-in stage dumps enabled at layer 2 (DS4_METAL_GRAPH_DUMP_*, which insert a synchronize+readback per stage), every dumped stage tensor of the failing pass is finite — including attn_comp_kv_raw/score_raw, KVcompress, states, indexer_scores, kqv_out, attn_out, ffn_* — yet the request still fails with nonfinite seed logits. So whatever goes nonfinite is either (a) a buffer not covered by the dump names, or (b) written after the dump reads by a later host-side write — we suspect a host tensor_write mid-encode clobbering data that already-encoded dispatches read at execution time (per-row substrate/count tables are the obvious candidates; a host write lands immediately while the batch CB is still pending).
  2. attn_state_kv reads fully finite while attn_state_score shows exactly half -inf — we verified against the kernels that this is the legitimate fill sentinel (state_kv zero-filled, state_score -INF-filled, only [0, ratio+rem) rows written), and the ratio4 pool kernels handle -inf rows correctly. Red herring; don't chase it.

Separate real bug found on the way (independent of the NaN): on the zero_prefix path, metal_graph_refresh_ratio4_compressor_state (ds4.c ~17112/~17922) overwrites the state that compressor_prefill_tensor already seeded to the CPU-oracle layout, discarding the partial current-window rows whenever n % ratio != 0 — the CPU oracle compressor_finish_prefill_state_cpu preserves rows [0, ratio+rem) and clears only the tail. It happens to agree at rem==0, so it is not the n=8 trigger, but it will corrupt the first decode token after any non-ratio-aligned prefill on Metal.

Happy to run any diagnostic build you want on the M3 Ultra — this box reproduces in seconds.

Builds on the three DSpark Metal ops in this branch. Four correctness fixes on
the continuous-batching + drafter path:

- compressor_update_tensor: the banked emit wrote F32 pool/rms/rope output
  through an sizeof(float)-strided view of the F16 comp cache, corrupting one
  element per comp row -> 100% NaN at the first compressed-KV attention layer
  once n_comp crosses the boundary. Caller-scoped output_is_f16: the banked
  (F16-cache) caller emits into an F32 scratch then copy_f32_to_f16 into the
  row; every F32 caller is unchanged.

- ds4_gpu_matmul_f32_tensor was GEMV-only (n_tok!=1 returned failure); the
  DSpark HC-mixer weight is F32 and the block runs it at n_tok=block size. Run
  n_tok independent GEMVs over per-row views; byte-identical for n_tok==1.

- ds4_gpu_tensor_copy blits into the current batch cb and fails when none is
  open; the DSpark inject block ends the cb, so mtp_cont_rollback_restore_all's
  restore copies failed. Open a cb around the rollback only when none is open
  (begin_commands returns 0 when already open -> MTP path unchanged).

- HC split/sinkhorn sigmoid: 0.5*tanh(0.5*z)+0.5 overflows to NaN under Metal
  fast-math tanh (exp-based) for large-positive z. Clamp the input to [-30,30]
  before the transcendental; sigmoid is saturated by +/-30 so in-range values
  are unchanged.

See METAL_DSPARK.md for details, credits, and observed (content-dependent)
acceptance. Root-cause analysis and fixes produced with AI assistance (Claude)
and reviewed before submission.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robotnursenyc robotnursenyc changed the title metal: implement the three DSpark drafter ops (capture_mean, gather_concat, markov_step) metal: DSpark drafter working end-to-end (3 ops + 4 continuous/drafter fixes) Jul 21, 2026
@robotnursenyc
robotnursenyc changed the base branch from main to batched-serving July 21, 2026 03:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants